/*
此源码是基于 XgpNwb 的二次修改
Github：https://github.com/NianBroken/Firework_Simulator
Gitee：https://gitee.com/nianbroken/Firework_Simulator
*/
'use strict';
console.clear();

// This is a prime example of what starts out as a simple project
// and snowballs way beyond its intended size. It's a little clunky
// reading/working on this single file, but here it is anyways :)

const IS_MOBILE = window.innerWidth <= 640;
const IS_DESKTOP = window.innerWidth > 800;
const IS_HEADER = IS_DESKTOP && window.innerHeight < 300;
// Detect high end devices. This will be a moving target.
const IS_HIGH_END_DEVICE = (() => {
  const hwConcurrency = navigator.hardwareConcurrency;
  if (!hwConcurrency) {
    return false;
  }
  // Large screens indicate a full size computer, which often have hyper threading these days.
  // So a quad core desktop machine has 8 cores. We'll place a higher min threshold there.
  const minCount = window.innerWidth <= 1024 ? 4 : 8;
  return hwConcurrency >= minCount;
})();
// Prevent canvases from getting too large on ridiculous screen sizes.
// 8K - can restrict this if needed
const MAX_WIDTH = 7680;
const MAX_HEIGHT = 4320;
const GRAVITY = 0.9; // Acceleration in px/s
let simSpeed = 1;

function getDefaultScaleFactor() {
  if (IS_DESKTOP) return 0.75;
  if (IS_MOBILE) return 1.00;
  if (IS_HEADER) return 1.00;
  return 1;
}

// Width/height values that take scale into account.
// USE THESE FOR DRAWING POSITIONS
let stageW, stageH;

// All quality globals will be overwritten and updated via `configDidUpdate`.
let quality = 1;
let isLowQuality = true;
let isNormalQuality = false;
let isHighQuality = false;

const QUALITY_LOW = 1;
const QUALITY_NORMAL = 2;
const QUALITY_HIGH = 3;

const SKY_LIGHT_NONE = 0;
const SKY_LIGHT_DIM = 1;
const SKY_LIGHT_NORMAL = 2;

const COLOR = {
  Red: '#ff0043',
  Green: '#14fc56',
  Blue: '#1e7fff',
  Purple: '#e60aff',
  Gold: '#ffbf36',
  White: '#ffffff'
};

// Special invisible color (not rendered, and therefore not in COLOR map)
const INVISIBLE = '_INVISIBLE_';

const PI_2 = Math.PI * 2;
const PI_HALF = Math.PI * 0.5;

// Stage.disableHighDPI = true;
const trailsStage = new Stage('trails-canvas');
const mainStage = new Stage('main-canvas');
const stages = [
  trailsStage,
  mainStage
];



// Fullscreen helpers, using Fscreen for prefixes.
function fullscreenEnabled() {
  return fscreen.fullscreenEnabled;
}

// Note that fullscreen state is synced to store, and the store should be the source
// of truth for whether the app is in fullscreen mode or not.
function isFullscreen() {
  return !!fscreen.fullscreenElement;
}

// Attempt to toggle fullscreen mode.
function toggleFullscreen() {
  if (fullscreenEnabled()) {
    if (isFullscreen()) {
      fscreen.exitFullscreen();
    } else {
      fscreen.requestFullscreen(document.documentElement);
    }
  }
}

// Sync fullscreen changes with store. An event listener is necessary because the user can
// toggle fullscreen mode directly through the browser, and we want to react to that.
// The language of this project was translated into Chinese by Nianbroken
fscreen.addEventListener('fullscreenchange', () => {
  store.setState({ fullscreen: isFullscreen() });
});




// Simple state container; the source of truth.
const store = {
  _listeners: new Set(),
  _dispatch(prevState) {
    this._listeners.forEach(listener => listener(this.state, prevState))
  },

  state: {
    // will be unpaused in init()
    paused: true,
    soundEnabled: true,
    menuOpen: false,
    openHelpTopic: null,
    fullscreen: isFullscreen(),
    // Note that config values used for <select>s must be strings, unless manually converting values to strings
    // at render time, and parsing on change.
    config: {
      quality: String(IS_HIGH_END_DEVICE ? QUALITY_HIGH : QUALITY_NORMAL), // will be mirrored to a global variable named `quality` in `configDidUpdate`, for perf.
      shell: 'Random',
      size: IS_DESKTOP
        ? '4' // Desktop default
        : IS_HEADER
          ? '1.2' // Profile header default (doesn't need to be an int)
          : '2', // Mobile default
      autoLaunch: false,
      finale: false,
      skyLighting: SKY_LIGHT_NORMAL + '',
      hideControls: IS_HEADER,
      longExposure: false,
      scaleFactor: getDefaultScaleFactor()
    }
  },

  setState(nextState) {
    const prevState = this.state;
    this.state = Object.assign({}, this.state, nextState);
    this._dispatch(prevState);
    this.persist();
  },

  subscribe(listener) {
    this._listeners.add(listener);
    return () => this._listeners.remove(listener);
  },

  // Load / persist select state to localStorage
  // Mutates state because `store.load()` should only be called once immediately after store is created, before any subscriptions.
  load() {
    const serializedData = localStorage.getItem('cm_fireworks_data');
    if (serializedData) {
      const {
        schemaVersion,
        data
      } = JSON.parse(serializedData);

      const config = this.state.config;
      switch(schemaVersion) {
        case '1.1':
          config.quality = data.quality;
          config.size = data.size;
          config.skyLighting = data.skyLighting;
          break;
        case '1.2':
          config.quality = data.quality;
          config.size = data.size;
          config.skyLighting = data.skyLighting;
          config.scaleFactor = data.scaleFactor;
          break;
        default:
          throw new Error('version switch should be exhaustive');
      }
      console.log(`Loaded config (schema version ${schemaVersion})`);
    }
    // Deprecated data format. Checked with care (it's not namespaced).
    else if (localStorage.getItem('schemaVersion') === '1') {
      let size;
      // Attempt to parse data, ignoring if there is an error.
      try {
        const sizeRaw = localStorage.getItem('configSize');
        size = typeof sizeRaw === 'string' && JSON.parse(sizeRaw);
      }
      catch(e) {
        console.log('Recovered from error parsing saved config:');
        console.error(e);
        return;
      }
      // Only restore validated values
      const sizeInt = parseInt(size, 10);
      if (sizeInt >= 0 && sizeInt <= 4) {
        this.state.config.size = String(sizeInt);
      }
    }
  },

  persist() {
    const config = this.state.config;
    localStorage.setItem('cm_fireworks_data', JSON.stringify({
      schemaVersion: '1.2',
      data: {
        quality: config.quality,
        size: config.size,
        skyLighting: config.skyLighting,
        scaleFactor: config.scaleFactor
      }
    }));
  }
};


if (!IS_HEADER) {
  store.load();
}

// Actions
// ---------

function togglePause(toggle) {
  const paused = store.state.paused;
  let newValue;
  if (typeof toggle === 'boolean') {
    newValue = toggle;
  } else {
    newValue = !paused;
  }

  if (paused !== newValue) {
    store.setState({ paused: newValue });
  }
}

function toggleSound(toggle) {
  if (typeof toggle === 'boolean') {
    store.setState({ soundEnabled: toggle });
  } else {
    store.setState({ soundEnabled: !store.state.soundEnabled });
  }
}

function toggleMenu(toggle) {
  if (typeof toggle === 'boolean') {
    store.setState({ menuOpen: toggle });
  } else {
    store.setState({ menuOpen: !store.state.menuOpen });
  }
}

function updateConfig(nextConfig) {
  nextConfig = nextConfig || getConfigFromDOM();
  store.setState({
    config: Object.assign({}, store.state.config, nextConfig)
  });

  configDidUpdate();
}

// Map config to various properties & apply side effects
function configDidUpdate() {
  const config = store.state.config;

  quality = qualitySelector();
  isLowQuality = quality === QUALITY_LOW;
  isNormalQuality = quality === QUALITY_NORMAL;
  isHighQuality = quality === QUALITY_HIGH;

  if (skyLightingSelector() === SKY_LIGHT_NONE) {
    appNodes.canvasContainer.style.backgroundColor = '#000';
  }

  Spark.drawWidth = quality === QUALITY_HIGH ? 0.75 : 1;
}

// Selectors
// -----------

const isRunning = (state=store.state) => !state.paused && !state.menuOpen;
// Whether user has enabled sound.
const soundEnabledSelector = (state=store.state) => state.soundEnabled;
// Whether any sounds are allowed, taking into account multiple factors.
const canPlaySoundSelector = (state=store.state) => isRunning(state) && soundEnabledSelector(state);
// Convert quality to number.
const qualitySelector = () => +store.state.config.quality;
const shellNameSelector = () => store.state.config.shell;
// Convert shell size to number.
const shellSizeSelector = () => +store.state.config.size;
const finaleSelector = () => store.state.config.finale;
const skyLightingSelector = () => +store.state.config.skyLighting;
const scaleFactorSelector = () => store.state.config.scaleFactor;



// Help Content
const helpContent = {
  shellType: {
    header: '烟花类型',
    body: '你要放的烟花的类型，选择“随机（Random）”可以获得非常好的体验！'
  },
  shellSize: {
    header: '烟花大小',
    body: '烟花越大绽放范围就越大，但是烟花越大，设备所需的性能也会增多，大的烟花可能导致你的设备卡顿。'
  },
  quality: {
    header: '画质',
    body: '如果动画运行不流畅，你可以试试降低画质。画质越高，烟花绽放后的火花数量就越多，但高画质可能导致你的设备卡顿。'
  },
  skyLighting: {
    header: '照亮天空',
    body: '烟花爆炸时，背景会被照亮。如果你的屏幕看起来太亮了，可以把它改成“暗”或者“不”。'
  },
  scaleFactor: {
    header: '缩放',
    body: '使你与烟花离得更近或更远。对于较大的烟花，你可以选择更小的缩放值，尤其是在手机或平板电脑上。'
  },
  autoLaunch: {
    header: '自动放烟花',
    body: '开启后你就可以坐在你的设备屏幕前面欣赏烟花了，你也可以关闭它，但关闭后你就只能通过点击屏幕的方式来放烟花。'
  },
  finaleMode: {
    header: '同时放更多的烟花',
    body: '可以在同一时间自动放出更多的烟花（但需要开启先开启“自动放烟花”）。'
  },
  hideControls: {
    header: '隐藏控制按钮',
    body: '隐藏屏幕顶部的按钮。如果你要截图，或者需要一个无缝的体验，你就可以将按钮隐藏，隐藏按钮后你仍然可以在右上角打开设置。'
  },
  fullscreen: {
    header: '全屏',
    body: '切换至全屏模式'
  },
  longExposure: {
    header: '保留烟花的火花',
    body: '可以保留烟花留下的火花'
  }
};

const nodeKeyToHelpKey = {
  shellTypeLabel: 'shellType',
  shellSizeLabel: 'shellSize',
  qualityLabel: 'quality',
  skyLightingLabel: 'skyLighting',
  scaleFactorLabel: 'scaleFactor',
  autoLaunchLabel: 'autoLaunch',
  finaleModeLabel: 'finaleMode',
  hideControlsLabel: 'hideControls',
  fullscreenLabel: 'fullscreen',
  longExposureLabel: 'longExposure'
};


// Render app UI / keep in sync with state
const appNodes = {
  stageContainer: '.stage-container',
  canvasContainer: '.canvas-container',
  controls: '.controls',
  menu: '.menu',
  menuInnerWrap: '.menu__inner-wrap',
  pauseBtn: '.pause-btn',
  pauseBtnSVG: '.pause-btn use',
  soundBtn: '.sound-btn',
  soundBtnSVG: '.sound-btn use',
  shellType: '.shell-type',
  shellTypeLabel: '.shell-type-label',
  shellSize: '.shell-size',
  shellSizeLabel: '.shell-size-label',
  quality: '.quality-ui',
  qualityLabel: '.quality-ui-label',
  skyLighting: '.sky-lighting',
  skyLightingLabel: '.sky-lighting-label',
  scaleFactor: '.scaleFactor',
  scaleFactorLabel: '.scaleFactor-label',
  autoLaunch: '.auto-launch',
  autoLaunchLabel: '.auto-launch-label',
  finaleModeFormOption: '.form-option--finale-mode',
  finaleMode: '.finale-mode',
  finaleModeLabel: '.finale-mode-label',
  hideControls: '.hide-controls',
  hideControlsLabel: '.hide-controls-label',
  fullscreenFormOption: '.form-option--fullscreen',
  fullscreen: '.fullscreen',
  fullscreenLabel: '.fullscreen-label',
  longExposure: '.long-exposure',
  longExposureLabel: '.long-exposure-label',

  // Help UI
  helpModal: '.help-modal',
  helpModalOverlay: '.help-modal__overlay',
  helpModalHeader: '.help-modal__header',
  helpModalBody: '.help-modal__body',
  helpModalCloseBtn: '.help-modal__close-btn'
};

// Convert appNodes selectors to dom nodes
Object.keys(appNodes).forEach(key => {
  appNodes[key] = document.querySelector(appNodes[key]);
});

// Remove fullscreen control if not supported.
if (!fullscreenEnabled()) {
  appNodes.fullscreenFormOption.classList.add('remove');
}

// First render is called in init()
function renderApp(state) {
  const pauseBtnIcon = `#icon-${state.paused ? 'play' : 'pause'}`;
  const soundBtnIcon = `#icon-sound-${soundEnabledSelector() ? 'on' : 'off'}`;
  appNodes.pauseBtnSVG.setAttribute('href', pauseBtnIcon);
  appNodes.pauseBtnSVG.setAttribute('xlink:href', pauseBtnIcon);
  appNodes.soundBtnSVG.setAttribute('href', soundBtnIcon);
  appNodes.soundBtnSVG.setAttribute('xlink:href', soundBtnIcon);
  appNodes.controls.classList.toggle('hide', state.menuOpen || state.config.hideControls);
  appNodes.canvasContainer.classList.toggle('blur', state.menuOpen);
  appNodes.menu.classList.toggle('hide', !state.menuOpen);
  appNodes.finaleModeFormOption.style.opacity = state.config.autoLaunch ? 1 : 0.32;

  appNodes.quality.value = state.config.quality;
  appNodes.shellType.value = state.config.shell;
  appNodes.shellSize.value = state.config.size;
  appNodes.autoLaunch.checked = state.config.autoLaunch;
  appNodes.finaleMode.checked = state.config.finale;
  appNodes.skyLighting.value = state.config.skyLighting;
  appNodes.hideControls.checked = state.config.hideControls;
  appNodes.fullscreen.checked = state.fullscreen;
  appNodes.longExposure.checked = state.config.longExposure;
  appNodes.scaleFactor.value = state.config.scaleFactor.toFixed(2);

  appNodes.menuInnerWrap.style.opacity = state.openHelpTopic ? 0.12 : 1;
  appNodes.helpModal.classList.toggle('active', !!state.openHelpTopic);
  if (state.openHelpTopic) {
    const { header, body } = helpContent[state.openHelpTopic];
    appNodes.helpModalHeader.textContent = header;
    appNodes.helpModalBody.textContent = body;
  }
}

store.subscribe(renderApp);

// Perform side effects on state changes
function handleStateChange(state, prevState) {
  const canPlaySound = canPlaySoundSelector(state);
  const canPlaySoundPrev = canPlaySoundSelector(prevState);

  if (canPlaySound !== canPlaySoundPrev) {
    if (canPlaySound) {
      soundManager.resumeAll();
    } else {
      soundManager.pauseAll();
    }
  }
}

store.subscribe(handleStateChange);


function getConfigFromDOM() {
  return {
    quality: appNodes.quality.value,
    shell: appNodes.shellType.value,
    size: appNodes.shellSize.value,
    autoLaunch: appNodes.autoLaunch.checked,
    finale: appNodes.finaleMode.checked,
    skyLighting: appNodes.skyLighting.value,
    longExposure: appNodes.longExposure.checked,
    hideControls: appNodes.hideControls.checked,
    // Store value as number.
    scaleFactor: parseFloat(appNodes.scaleFactor.value)
  };
};

const updateConfigNoEvent = () => updateConfig();
appNodes.quality.addEventListener('input', updateConfigNoEvent);
appNodes.shellType.addEventListener('input', updateConfigNoEvent);
appNodes.shellSize.addEventListener('input', updateConfigNoEvent);
appNodes.autoLaunch.addEventListener('click', () => setTimeout(updateConfig, 0));
appNodes.finaleMode.addEventListener('click', () => setTimeout(updateConfig, 0));
appNodes.skyLighting.addEventListener('input', updateConfigNoEvent);
appNodes.longExposure.addEventListener('click', () => setTimeout(updateConfig, 0));
appNodes.hideControls.addEventListener('click', () => setTimeout(updateConfig, 0));
appNodes.fullscreen.addEventListener('click', () => setTimeout(toggleFullscreen, 0));
// Changing scaleFactor requires triggering resize handling code as well.
appNodes.scaleFactor.addEventListener('input', () => {
  updateConfig();
  handleResize();
});

Object.keys(nodeKeyToHelpKey).forEach(nodeKey => {
  const helpKey = nodeKeyToHelpKey[nodeKey];
  appNodes[nodeKey].addEventListener('click', () => {
    store.setState({ openHelpTopic: helpKey });
  });
});

appNodes.helpModalCloseBtn.addEventListener('click', () => {
  store.setState({ openHelpTopic: null });
});

appNodes.helpModalOverlay.addEventListener('click', () => {
  store.setState({ openHelpTopic: null });
});



// Constant derivations
const COLOR_NAMES = Object.keys(COLOR);
const COLOR_CODES = COLOR_NAMES.map(colorName => COLOR[colorName]);
// Invisible stars need an indentifier, even through they won't be rendered - physics still apply.
const COLOR_CODES_W_INVIS = [...COLOR_CODES, INVISIBLE];
// Map of color codes to their index in the array. Useful for quickly determining if a color has already been updated in a loop.
const COLOR_CODE_INDEXES = COLOR_CODES_W_INVIS.reduce((obj, code, i) => {
  obj[code] = i;
  return obj;
}, {});
// Tuples is a map keys by color codes (hex) with values of { r, g, b } tuples (still just objects).
const COLOR_TUPLES = {};
COLOR_CODES.forEach(hex => {
  COLOR_TUPLES[hex] = {
    r: parseInt(hex.substr(1, 2), 16),
    g: parseInt(hex.substr(3, 2), 16),
    b: parseInt(hex.substr(5, 2), 16),
  };
});

// Get a random color.
function randomColorSimple() {
  return COLOR_CODES[Math.random() * COLOR_CODES.length | 0];
}

// Get a random color, with some customization options available.
let lastColor;
function randomColor(options) {
  const notSame = options && options.notSame;
  const notColor = options && options.notColor;
  const limitWhite = options && options.limitWhite;
  let color = randomColorSimple();

  // limit the amount of white chosen randomly
  if (limitWhite && color === COLOR.White && Math.random() < 0.6) {
    color = randomColorSimple();
  }

  if (notSame) {
    while (color === lastColor) {
      color = randomColorSimple();
    }
  }
  else if (notColor) {
    while (color === notColor) {
      color = randomColorSimple();
    }
  }

  lastColor = color;
  return color;
}

function whiteOrGold() {
  return Math.random() < 0.5 ? COLOR.Gold : COLOR.White;
}


// Shell helpers
function makePistilColor(shellColor) {
  return (shellColor === COLOR.White || shellColor === COLOR.Gold) ? randomColor({ notColor: shellColor }) : whiteOrGold();
}

// Unique shell types
const crysanthemumShell = (size=1) => {
  const glitter = Math.random() < 0.25;
  const singleColor = Math.random() < 0.72;
  const color = singleColor ? randomColor({ limitWhite: true }) : [randomColor(), randomColor({ notSame: true })];
  const pistil = singleColor && Math.random() < 0.42;
  const pistilColor = pistil && makePistilColor(color);
  const secondColor = singleColor && (Math.random() < 0.2 || color === COLOR.White) ? pistilColor || randomColor({ notColor: color, limitWhite: true }) : null;
  const streamers = !pistil && color !== COLOR.White && Math.random() < 0.42;
  let starDensity = glitter ? 1.1 : 1.25;
  if (isLowQuality) starDensity *= 0.8;
  if (isHighQuality) starDensity = 1.2;
  return {
    shellSize: size,
    spreadSize: 300 + size * 100,
    starLife: 900 + size * 200,
    starDensity,
    color,
    secondColor,
    glitter: glitter ? 'light' : '',
    glitterColor: whiteOrGold(),
    pistil,
    pistilColor,
    streamers
  };
};


const ghostShell = (size=1) => {
  // Extend crysanthemum shell
  const shell = crysanthemumShell(size);
  // Ghost effect can be fast, so extend star life
  shell.starLife *= 1.5;
  // Ensure we always have a single color other than white
  let ghostColor = randomColor({ notColor: COLOR.White });
  // Always use streamers, and sometimes a pistil
  shell.streamers = true;
  const pistil = Math.random() < 0.42;
  const pistilColor = pistil && makePistilColor(ghostColor);
  // Ghost effect - transition from invisible to chosen color
  shell.color = INVISIBLE;
  shell.secondColor = ghostColor;
  // We don't want glitter to be spewed by invisible stars, and we don't currently
  // have a way to transition glitter state. So we'll disable it.
  shell.glitter = '';

  return shell;
};


const strobeShell = (size=1) => {
  const color = randomColor({ limitWhite: true });
  return {
    shellSize: size,
    spreadSize: 280 + size * 92,
    starLife: 1100 + size * 200,
    starLifeVariation: 0.40,
    starDensity: 1.1,
    color,
    glitter: 'light',
    glitterColor: COLOR.White,
    strobe: true,
    strobeColor: Math.random() < 0.5 ? COLOR.White : null,
    pistil: Math.random() < 0.5,
    pistilColor: makePistilColor(color)
  };
};


const palmShell = (size=1) => {
  const color = randomColor();
  const thick = Math.random() < 0.5;
  return {
    shellSize: size,
    color,
    spreadSize: 250 + size * 75,
    starDensity: thick ? 0.15 : 0.4,
    starLife: 1800 + size * 200,
    glitter: thick ? 'thick' : 'heavy'
  };
};

const ringShell = (size=1) => {
  const color = randomColor();
  const pistil = Math.random() < 0.75;
  return {
    shellSize: size,
    ring: true,
    color,
    spreadSize: 300 + size * 100,
    starLife: 900 + size * 200,
    starCount: 2.2 * PI_2 * (size+1),
    pistil,
    pistilColor: makePistilColor(color),
    glitter: !pistil ? 'light' : '',
    glitterColor: color === COLOR.Gold ? COLOR.Gold : COLOR.White,
    streamers: Math.random() < 0.3
  };
  // return Object.assign({}, defaultShell, config);
};

const crossetteShell = (size=1) => {
  const color = randomColor({ limitWhite: true });
  return {
    shellSize: size,
    spreadSize: 300 + size * 100,
    starLife: 750 + size * 160,
    starLifeVariation: 0.4,
    starDensity: 0.85,
    color,
    crossette: true,
    pistil: Math.random() < 0.5,
    pistilColor: makePistilColor(color)
  };
};

const floralShell = (size=1) => ({
  shellSize: size,
  spreadSize: 300 + size * 120,
  starDensity: 0.12,
  starLife: 500 + size * 50,
  starLifeVariation: 0.5,
  color: Math.random() < 0.65 ? 'random' : (Math.random() < 0.15 ? randomColor() : [randomColor(), randomColor({ notSame: true })]),
  floral: true
});

const fallingLeavesShell = (size=1) => ({
  shellSize: size,
  color: INVISIBLE,
  spreadSize: 300 + size * 120,
  starDensity: 0.12,
  starLife: 500 + size * 50,
  starLifeVariation: 0.5,
  glitter: 'medium',
  glitterColor: COLOR.Gold,
  fallingLeaves: true
});

const willowShell = (size=1) => ({
  shellSize: size,
  spreadSize: 300 + size * 100,
  starDensity: 0.6,
  starLife: 3000 + size * 300,
  glitter: 'willow',
  glitterColor: COLOR.Gold,
  color: INVISIBLE
});

const crackleShell = (size=1) => {
  // favor gold
  const color = Math.random() < 0.75 ? COLOR.Gold : randomColor();
  return {
    shellSize: size,
    spreadSize: 380 + size * 75,
    starDensity: isLowQuality ? 0.65 : 1,
    starLife: 600 + size * 100,
    starLifeVariation: 0.32,
    glitter: 'light',
    glitterColor: COLOR.Gold,
    color,
    crackle: true,
    pistil: Math.random() < 0.65,
    pistilColor: makePistilColor(color)
  };
};

const horsetailShell = (size=1) => {
  const color = randomColor();
  return {
    shellSize: size,
    horsetail: true,
    color,
    spreadSize: 250 + size * 38,
    starDensity: 0.9,
    starLife: 2500 + size * 300,
    glitter: 'medium',
    glitterColor: Math.random() < 0.5 ? whiteOrGold() : color,
    // Add strobe effect to white horsetails, to make them more interesting
    strobe: color === COLOR.White
  };
};

function randomShellName() {
  return Math.random() < 0.5 ? 'Crysanthemum' : shellNames[(Math.random() * (shellNames.length - 1) + 1) | 0 ];
}

function randomShell(size) {
  // Special selection for codepen header.
  if (IS_HEADER) return randomFastShell()(size);
  // Normal operation
  return shellTypes[randomShellName()](size);
}

function shellFromConfig(size) {
  return shellTypes[shellNameSelector()](size);
}

// Get a random shell, not including processing intensive varients
// Note this is only random when "Random" shell is selected in config.
// Also, this does not create the shell, only returns the factory function.
const fastShellBlacklist = ['Falling Leaves', 'Floral', 'Willow'];
function randomFastShell() {
  const isRandom = shellNameSelector() === 'Random';
  let shellName = isRandom ? randomShellName() : shellNameSelector();
  if (isRandom) {
    while (fastShellBlacklist.includes(shellName)) {
      shellName = randomShellName();
    }
  }
  return shellTypes[shellName];
}


const shellTypes = {
  'Random': randomShell,
  'Crackle': crackleShell,
  'Crossette': crossetteShell,
  'Crysanthemum': crysanthemumShell,
  'Falling Leaves': fallingLeavesShell,
  'Floral': floralShell,
  'Ghost': ghostShell,
  'Horse Tail': horsetailShell,
  'Palm': palmShell,
  'Ring': ringShell,
  'Strobe': strobeShell,
  'Willow': willowShell
};

const shellNames = Object.keys(shellTypes);

function init() {
  // Remove loading state
  document.querySelector('.loading-init').remove();
  appNodes.stageContainer.classList.remove('remove');

  // Populate dropdowns
  function setOptionsForSelect(node, options) {
    node.innerHTML = options.reduce((acc, opt) => acc += `<option value="${opt.value}">${opt.label}</option>`, '');
  }

  // shell type
  let options = '';
  shellNames.forEach(opt => options += `<option value="${opt}">${opt}</option>`);
  appNodes.shellType.innerHTML = options;
  // shell size
  options = '';
  ['3"', '4"', '6"', '8"', '12"', '16"'].forEach((opt, i) => options += `<option value="${i}">${opt}</option>`);
  appNodes.shellSize.innerHTML = options;

  setOptionsForSelect(appNodes.quality, [
    { label: '低', value: QUALITY_LOW },
    { label: '正常', value: QUALITY_NORMAL },
    { label: '高', value: QUALITY_HIGH }
  ]);

  setOptionsForSelect(appNodes.skyLighting, [
    { label: '不', value: SKY_LIGHT_NONE },
    { label: '暗', value: SKY_LIGHT_DIM },
    { label: '正常', value: SKY_LIGHT_NORMAL }
  ]);

  // 0.9 is mobile default
  setOptionsForSelect(
    appNodes.scaleFactor,
    [0.5, 0.62, 0.75, 0.9, 1.0, 1.5, 2.0]
      .map(value => ({ value: value.toFixed(2), label: `${value*100}%` }))
  );

  // Begin simulation
  togglePause(false);

  // initial render
  renderApp(store.state);

  // Apply initial config
  configDidUpdate();
}


function fitShellPositionInBoundsH(position) {
  const edge = 0.18;
  return (1 - edge*2) * position + edge;
}

function fitShellPositionInBoundsV(position) {
  return position * 0.75;
}

function getRandomShellPositionH() {
  return fitShellPositionInBoundsH(Math.random());
}

function getRandomShellPositionV() {
  return fitShellPositionInBoundsV(Math.random());
}

function getRandomShellSize() {
  const baseSize = shellSizeSelector();
  const maxVariance = Math.min(2.5, baseSize);
  const variance = Math.random() * maxVariance;
  const size = baseSize - variance;
  const height = maxVariance === 0 ? Math.random() : 1 - (variance / maxVariance);
  const centerOffset = Math.random() * (1 - height * 0.65) * 0.5;
  const x = Math.random() < 0.5 ? 0.5 - centerOffset : 0.5 + centerOffset;
  return {
    size,
    x: fitShellPositionInBoundsH(x),
    height: fitShellPositionInBoundsV(height)
  };
}


// Launches a shell from a user pointer event, based on state.config
function launchShellFromConfig(event) {
  const shell = new Shell(shellFromConfig(shellSizeSelector()));
  const w = mainStage.width;
  const h = mainStage.height;

  shell.launch(
    event ? event.x / w : getRandomShellPositionH(),
    event ? 1 - event.y / h : getRandomShellPositionV()
  );
}


// Sequences
// -----------

function seqRandomShell() {
  const size = getRandomShellSize();
  const shell = new Shell(shellFromConfig(size.size));
  shell.launch(size.x, size.height);

  let extraDelay = shell.starLife;
  if (shell.fallingLeaves) {
    extraDelay = 4600;
  }

  return 900 + Math.random() * 600 + extraDelay;
}

function seqRandomFastShell() {
  const shellType = randomFastShell();
  const size = getRandomShellSize();
  const shell = new Shell(shellType(size.size));
  shell.launch(size.x, size.height);

  let extraDelay = shell.starLife;

  return 900 + Math.random() * 600 + extraDelay;
}

function seqTwoRandom() {
  const size1 = getRandomShellSize();
  const size2 = getRandomShellSize();
  const shell1 = new Shell(shellFromConfig(size1.size));
  const shell2 = new Shell(shellFromConfig(size2.size));
  const leftOffset = Math.random() * 0.2 - 0.1;
  const rightOffset = Math.random() * 0.2 - 0.1;
  shell1.launch(0.3 + leftOffset, size1.height);
  setTimeout(() => {
    shell2.launch(0.7 + rightOffset, size2.height);
  }, 100);

  let extraDelay = Math.max(shell1.starLife, shell2.starLife);
  if (shell1.fallingLeaves || shell2.fallingLeaves) {
    extraDelay = 4600;
  }

  return 900 + Math.random() * 600 + extraDelay;
}

function seqTriple() {
  const shellType = randomFastShell();
  const baseSize = shellSizeSelector();
  const smallSize = Math.max(0, baseSize - 1.25);

  const offset = Math.random() * 0.08 - 0.04;
  const shell1 = new Shell(shellType(baseSize));
  shell1.launch(0.5 + offset, 0.7);

  const leftDelay = 1000 + Math.random() * 400;
  const rightDelay = 1000 + Math.random() * 400;

  setTimeout(() => {
    const offset = Math.random() * 0.08 - 0.04;
    const shell2 = new Shell(shellType(smallSize));
    shell2.launch(0.2 + offset, 0.1);
  }, leftDelay);

  setTimeout(() => {
    const offset = Math.random() * 0.08 - 0.04;
    const shell3 = new Shell(shellType(smallSize));
    shell3.launch(0.8 + offset, 0.1);
  }, rightDelay);

  return 4000;
}

function seqPyramid() {
  const barrageCountHalf = IS_DESKTOP ? 7 : 4;
  const largeSize = shellSizeSelector();
  const smallSize = Math.max(0, largeSize - 3);
  const randomMainShell = Math.random() < 0.78 ? crysanthemumShell : ringShell;
  const randomSpecialShell = randomShell;

  function launchShell(x, useSpecial) {
    const isRandom = shellNameSelector() === 'Random';
    let shellType = isRandom
      ? useSpecial ? randomSpecialShell : randomMainShell
      : shellTypes[shellNameSelector()];
    const shell = new Shell(shellType(useSpecial ? largeSize : smallSize));
    const height = x <= 0.5 ? x / 0.5 : (1 - x) / 0.5;
    shell.launch(x, useSpecial ? 0.75 : height * 0.42);
  }

  let count = 0;
  let delay = 0;
  while(count <= barrageCountHalf) {
    if (count === barrageCountHalf) {
      setTimeout(() => {
        launchShell(0.5, true);
      }, delay);
    } else {
      const offset = count / barrageCountHalf * 0.5;
      const delayOffset = Math.random() * 30 + 30;
      setTimeout(() => {
        launchShell(offset, false);
      }, delay);
      setTimeout(() => {
        launchShell(1 - offset, false);
      }, delay + delayOffset);
    }

    count++;
    delay += 200;
  }

  return 3400 + barrageCountHalf * 250;
}

function seqSmallBarrage() {
  seqSmallBarrage.lastCalled = Date.now();
  const barrageCount = IS_DESKTOP ? 11 : 5;
  const specialIndex = IS_DESKTOP ? 3 : 1;
  const shellSize = Math.max(0, shellSizeSelector() - 2);
  const randomMainShell = Math.random() < 0.78 ? crysanthemumShell : ringShell;
  const randomSpecialShell = randomFastShell();

  // (cos(x*5π+0.5π)+1)/2 is a custom wave bounded by 0 and 1 used to set varying launch heights
  function launchShell(x, useSpecial) {
    const isRandom = shellNameSelector() === 'Random';
    let shellType = isRandom
      ? useSpecial ? randomSpecialShell : randomMainShell
      : shellTypes[shellNameSelector()];
    const shell = new Shell(shellType(shellSize));
    const height = (Math.cos(x*5*Math.PI + PI_HALF) + 1) / 2;
    shell.launch(x, height * 0.75);
  }

  let count = 0;
  let delay = 0;
  while(count < barrageCount) {
    if (count === 0) {
      launchShell(0.5, false)
      count += 1;
    }
    else {
      const offset = (count + 1) / barrageCount / 2;
      const delayOffset = Math.random() * 30 + 30;
      const useSpecial = count === specialIndex;
      setTimeout(() => {
        launchShell(0.5 + offset, useSpecial);
      }, delay);
      setTimeout(() => {
        launchShell(0.5 - offset, useSpecial);
      }, delay + delayOffset);
      count += 2;
    }
    delay += 200;
  }

  return 3400 + barrageCount * 120;
}
seqSmallBarrage.cooldown = 15000;
seqSmallBarrage.lastCalled = Date.now();


const sequences = [
  seqRandomShell,
  seqTwoRandom,
  seqTriple,
  seqPyramid,
  seqSmallBarrage
];


let isFirstSeq = true;
const finaleCount = 32;
let currentFinaleCount = 0;
function startSequence() {
  if (isFirstSeq) {
    isFirstSeq = false;
    if (IS_HEADER) {
      return seqTwoRandom();
    }
    else {
      const shell = new Shell(crysanthemumShell(shellSizeSelector()));
      shell.launch(0.5, 0.5);
      return 2400;
    }
  }

  if (finaleSelector()) {
    seqRandomFastShell();
    if (currentFinaleCount < finaleCount) {
      currentFinaleCount++;
      return 170;
    }
    else {
      currentFinaleCount = 0;
      return 6000;
    }
  }

  const rand = Math.random();

  if (rand < 0.08 && Date.now() - seqSmallBarrage.lastCalled > seqSmallBarrage.cooldown) {
    return seqSmallBarrage();
  }

  if (rand < 0.1) {
    return seqPyramid();
  }

  if (rand < 0.6 && !IS_HEADER) {
    return seqRandomShell();
  }
  else if (rand < 0.8) {
    return seqTwoRandom();
  }
  else if (rand < 1) {
    return seqTriple();
  }
}


let activePointerCount = 0;
let isUpdatingSpeed = false;

function handlePointerStart(event) {
  activePointerCount++;
  const btnSize = 50;

  if (event.y < btnSize) {
    if (event.x < btnSize) {
      togglePause();
      return;
    }
    if (event.x > mainStage.width/2 - btnSize/2 && event.x < mainStage.width/2 + btnSize/2) {
      toggleSound();
      return;
    }
    if (event.x > mainStage.width - btnSize) {
      toggleMenu();
      return;
    }
  }

  if (!isRunning()) return;

  if (updateSpeedFromEvent(event)) {
    isUpdatingSpeed = true;
  }
  else if (event.onCanvas) {
    launchShellFromConfig(event);
  }
}

function handlePointerEnd(event) {
  activePointerCount--;
  isUpdatingSpeed = false;
}

function handlePointerMove(event) {
  if (!isRunning()) return;

  if (isUpdatingSpeed) {
    updateSpeedFromEvent(event);
  }
}

function handleKeydown(event) {
  // P
  if (event.keyCode === 80) {
    togglePause();
  }
  // O
  else if (event.keyCode === 79) {
    toggleMenu();
  }
  // Esc
  else if (event.keyCode === 27) {
    toggleMenu(false);
  }
}

mainStage.addEventListener('pointerstart', handlePointerStart);
mainStage.addEventListener('pointerend', handlePointerEnd);
mainStage.addEventListener('pointermove', handlePointerMove);
window.addEventListener('keydown', handleKeydown);


// Account for window resize and custom scale changes.
function handleResize() {
  const w = window.innerWidth;
  const h = window.innerHeight;
  // Try to adopt screen size, heeding maximum sizes specified
  const containerW = Math.min(w, MAX_WIDTH);
  // On small screens, use full device height
  const containerH = w <= 420 ? h : Math.min(h, MAX_HEIGHT);
  appNodes.stageContainer.style.width = containerW + 'px';
  appNodes.stageContainer.style.height = containerH + 'px';
  stages.forEach(stage => stage.resize(containerW, containerH));
  // Account for scale
  const scaleFactor = scaleFactorSelector();
  stageW = containerW / scaleFactor;
  stageH = containerH / scaleFactor;
}

// Compute initial dimensions
handleResize();

window.addEventListener('resize', handleResize);


// Dynamic globals
let currentFrame = 0;
let speedBarOpacity = 0;
let autoLaunchTime = 0;

function updateSpeedFromEvent(event) {
  if (isUpdatingSpeed || event.y >= mainStage.height - 44) {
    // On phones it's hard to hit the edge pixels in order to set speed at 0 or 1, so some padding is provided to make that easier.
    const edge = 16;
    const newSpeed = (event.x - edge) / (mainStage.width - edge * 2);
    simSpeed = Math.min(Math.max(newSpeed, 0), 1);
    // show speed bar after an update
    speedBarOpacity = 1;
    // If we updated the speed, return true
    return true;
  }
  // Return false if the speed wasn't updated
  return false;
}


// Extracted function to keep `update()` optimized
function updateGlobals(timeStep, lag) {
  currentFrame++;

  // Always try to fade out speed bar
  if (!isUpdatingSpeed) {
    speedBarOpacity -= lag / 30; // half a second
    if (speedBarOpacity < 0) {
      speedBarOpacity = 0;
    }
  }

  // auto launch shells
  if (store.state.config.autoLaunch) {
    autoLaunchTime -= timeStep;
    if (autoLaunchTime <= 0) {
      autoLaunchTime = startSequence() * 1.25;
    }
  }
}


function update(frameTime, lag) {
  if (!isRunning()) return;

  const width = stageW;
  const height = stageH;
  const timeStep = frameTime * simSpeed;
  const speed = simSpeed * lag;

  updateGlobals(timeStep, lag);

  const starDrag = 1 - (1 - Star.airDrag) * speed;
  const starDragHeavy = 1 - (1 - Star.airDragHeavy) * speed;
  const sparkDrag = 1 - (1 - Spark.airDrag) * speed;
  const gAcc = timeStep / 1000 * GRAVITY;
  COLOR_CODES_W_INVIS.forEach(color => {
    // Stars
    const stars = Star.active[color];
    for (let i=stars.length-1; i>=0; i=i-1) {
      const star = stars[i];
      // Only update each star once per frame. Since color can change, it's possible a star could update twice without this, leading to a "jump".
      if (star.updateFrame === currentFrame) {
        continue;
      }
      star.updateFrame = currentFrame;

      star.life -= timeStep;
      if (star.life <= 0) {
        stars.splice(i, 1);
        Star.returnInstance(star);
      } else {
        const burnRate = Math.pow(star.life / star.fullLife, 0.5);
        const burnRateInverse = 1 - burnRate;

        star.prevX = star.x;
        star.prevY = star.y;
        star.x += star.speedX * speed;
        star.y += star.speedY * speed;
        // Apply air drag if star isn't "heavy". The heavy property is used for the shell comets.
        if (!star.heavy) {
          star.speedX *= starDrag;
          star.speedY *= starDrag;
        }
        else {
          star.speedX *= starDragHeavy;
          star.speedY *= starDragHeavy;
        }
        star.speedY += gAcc;

        if (star.spinRadius) {
          star.spinAngle += star.spinSpeed * speed;
          star.x += Math.sin(star.spinAngle) * star.spinRadius * speed;
          star.y += Math.cos(star.spinAngle) * star.spinRadius * speed;
        }

        if (star.sparkFreq) {
          star.sparkTimer -= timeStep;
          while (star.sparkTimer < 0) {
            star.sparkTimer += star.sparkFreq * 0.75 + star.sparkFreq * burnRateInverse * 4;
            Spark.add(
              star.x,
              star.y,
              star.sparkColor,
              Math.random() * PI_2,
              Math.random() * star.sparkSpeed * burnRate,
              star.sparkLife * 0.8 + Math.random() * star.sparkLifeVariation * star.sparkLife
            );
          }
        }

        // Handle star transitions
        if (star.life < star.transitionTime) {
          if (star.secondColor && !star.colorChanged) {
            star.colorChanged = true;
            star.color = star.secondColor;
            stars.splice(i, 1);
            Star.active[star.secondColor].push(star);
            if (star.secondColor === INVISIBLE) {
              star.sparkFreq = 0;
            }
          }

          if (star.strobe) {
            // Strobes in the following pattern: on:off:off:on:off:off in increments of `strobeFreq` ms.
            star.visible = Math.floor(star.life / star.strobeFreq) % 3 === 0;
          }
        }
      }
    }

    // Sparks
    const sparks = Spark.active[color];
    for (let i=sparks.length-1; i>=0; i=i-1) {
      const spark = sparks[i];
      spark.life -= timeStep;
      if (spark.life <= 0) {
        sparks.splice(i, 1);
        Spark.returnInstance(spark);
      } else {
        spark.prevX = spark.x;
        spark.prevY = spark.y;
        spark.x += spark.speedX * speed;
        spark.y += spark.speedY * speed;
        spark.speedX *= sparkDrag;
        spark.speedY *= sparkDrag;
        spark.speedY += gAcc;
      }
    }
  });

  render(speed);
}

function render(speed) {
  const { dpr } = mainStage;
  const width = stageW;
  const height = stageH;
  const trailsCtx = trailsStage.ctx;
  const mainCtx = mainStage.ctx;

  if (skyLightingSelector() !== SKY_LIGHT_NONE) {
    colorSky(speed);
  }

  // Account for high DPI screens, and custom scale factor.
  const scaleFactor = scaleFactorSelector();
  trailsCtx.scale(dpr * scaleFactor, dpr * scaleFactor);
  mainCtx.scale(dpr * scaleFactor, dpr * scaleFactor);

  trailsCtx.globalCompositeOperation = 'source-over';
  trailsCtx.fillStyle = `rgba(0, 0, 0, ${store.state.config.longExposure ? 0.0025 : 0.175 * speed})`;
  trailsCtx.fillRect(0, 0, width, height);

  mainCtx.clearRect(0, 0, width, height);

  // Draw queued burst flashes
  // These must also be drawn using source-over due to Safari. Seems rendering the gradients using lighten draws large black boxes instead.
  // Thankfully, these burst flashes look pretty much the same either way.
  // The language of this project was translated into Chinese by Nianbroken
  while (BurstFlash.active.length) {
    const bf = BurstFlash.active.pop();

    const burstGradient = trailsCtx.createRadialGradient(bf.x, bf.y, 0, bf.x, bf.y, bf.radius);
    burstGradient.addColorStop(0.024, 'rgba(255, 255, 255, 1)');
    burstGradient.addColorStop(0.125, 'rgba(255, 160, 20, 0.2)');
    burstGradient.addColorStop(0.32, 'rgba(255, 140, 20, 0.11)');
    burstGradient.addColorStop(1, 'rgba(255, 120, 20, 0)');
    trailsCtx.fillStyle = burstGradient;
    trailsCtx.fillRect(bf.x - bf.radius, bf.y - bf.radius, bf.radius * 2, bf.radius * 2);

    BurstFlash.returnInstance(bf);
  }

  // Remaining drawing on trails canvas will use 'lighten' blend mode
  trailsCtx.globalCompositeOperation = 'lighten';

  // Draw stars
  trailsCtx.lineWidth = Star.drawWidth;
  trailsCtx.lineCap = isLowQuality ? 'square' : 'round';
  mainCtx.strokeStyle = '#fff';
  mainCtx.lineWidth = 1;
  mainCtx.beginPath();
  COLOR_CODES.forEach(color => {
    const stars = Star.active[color];
    trailsCtx.strokeStyle = color;
    trailsCtx.beginPath();
    stars.forEach(star => {
      if (star.visible) {
        trailsCtx.moveTo(star.x, star.y);
        trailsCtx.lineTo(star.prevX, star.prevY);
        mainCtx.moveTo(star.x, star.y);
        mainCtx.lineTo(star.x - star.speedX * 1.6, star.y - star.speedY * 1.6);
      }
    });
    trailsCtx.stroke();
  });
  mainCtx.stroke();

  // Draw sparks
  trailsCtx.lineWidth = Spark.drawWidth;
  trailsCtx.lineCap = 'butt';
  COLOR_CODES.forEach(color => {
    const sparks = Spark.active[color];
    trailsCtx.strokeStyle = color;
    trailsCtx.beginPath();
    sparks.forEach(spark => {
      trailsCtx.moveTo(spark.x, spark.y);
      trailsCtx.lineTo(spark.prevX, spark.prevY);
    });
    trailsCtx.stroke();
  });


  // Render speed bar if visible
  if (speedBarOpacity) {
    const speedBarHeight = 6;
    mainCtx.globalAlpha = speedBarOpacity;
    mainCtx.fillStyle = COLOR.Blue;
    mainCtx.fillRect(0, height - speedBarHeight, width * simSpeed, speedBarHeight);
    mainCtx.globalAlpha = 1;
  }


  trailsCtx.setTransform(1, 0, 0, 1, 0, 0);
  mainCtx.setTransform(1, 0, 0, 1, 0, 0);
}


// Draw colored overlay based on combined brightness of stars (light up the sky!)
// Note: this is applied to the canvas container's background-color, so it's behind the particles
const currentSkyColor = { r: 0, g: 0, b: 0 };
const targetSkyColor = { r: 0, g: 0, b: 0 };
function colorSky(speed) {
  // The maximum r, g, or b value that will be used (255 would represent no maximum)
  const maxSkySaturation = skyLightingSelector() * 15;
  // How many stars are required in total to reach maximum sky brightness
  const maxStarCount = 500;
  let totalStarCount = 0;
  // Initialize sky as black
  targetSkyColor.r = 0;
  targetSkyColor.g = 0;
  targetSkyColor.b = 0;
  // Add each known color to sky, multiplied by particle count of that color. This will put RGB values wildly out of bounds, but we'll scale them back later.
  // Also add up total star count.
  COLOR_CODES.forEach(color => {
    const tuple = COLOR_TUPLES[color];
    const count =  Star.active[color].length;
    totalStarCount += count;
    targetSkyColor.r += tuple.r * count;
    targetSkyColor.g += tuple.g * count;
    targetSkyColor.b += tuple.b * count;
  });

  // Clamp intensity at 1.0, and map to a custom non-linear curve. This allows few stars to perceivably light up the sky, while more stars continue to increase the brightness but at a lesser rate. This is more inline with humans' non-linear brightness perception.
  const intensity = Math.pow(Math.min(1, totalStarCount / maxStarCount), 0.3);
  // Figure out which color component has the highest value, so we can scale them without affecting the ratios.
  // Prevent 0 from being used, so we don't divide by zero in the next step.
  const maxColorComponent = Math.max(1, targetSkyColor.r, targetSkyColor.g, targetSkyColor.b);
  // Scale all color components to a max of `maxSkySaturation`, and apply intensity.
  targetSkyColor.r = targetSkyColor.r / maxColorComponent * maxSkySaturation * intensity;
  targetSkyColor.g = targetSkyColor.g / maxColorComponent * maxSkySaturation * intensity;
  targetSkyColor.b = targetSkyColor.b / maxColorComponent * maxSkySaturation * intensity;

  // Animate changes to color to smooth out transitions.
  const colorChange = 10;
  currentSkyColor.r += (targetSkyColor.r - currentSkyColor.r) / colorChange * speed;
  currentSkyColor.g += (targetSkyColor.g - currentSkyColor.g) / colorChange * speed;
  currentSkyColor.b += (targetSkyColor.b - currentSkyColor.b) / colorChange * speed;

  appNodes.canvasContainer.style.backgroundColor = `rgb(${currentSkyColor.r | 0}, ${currentSkyColor.g | 0}, ${currentSkyColor.b | 0})`;
}

mainStage.addEventListener('ticker', update);


// Helper used to semi-randomly spread particles over an arc
// Values are flexible - `start` and `arcLength` can be negative, and `randomness` is simply a multiplier for random addition.
function createParticleArc(start, arcLength, count, randomness, particleFactory) {
  const angleDelta = arcLength / count;
  // Sometimes there is an extra particle at the end, too close to the start. Subtracting half the angleDelta ensures that is skipped.
  // Would be nice to fix this a better way.
  const end = start + arcLength - (angleDelta * 0.5);

  if (end > start) {
    // Optimization: `angle=angle+angleDelta` vs. angle+=angleDelta
    // V8 deoptimises with let compound assignment
    for (let angle=start; angle<end; angle=angle+angleDelta) {
      particleFactory(angle + Math.random() * angleDelta * randomness);
    }
  } else {
    for (let angle=start; angle>end; angle=angle+angleDelta) {
      particleFactory(angle + Math.random() * angleDelta * randomness);
    }
  }
}


/**
 * Helper used to create a spherical burst of particles.
 *
 * @param  {Number} count               The desired number of stars/particles. This value is a suggestion, and the
 *                                      created burst may have more particles. The current algorithm can't perfectly
 *                                      distribute a specific number of points evenly on a sphere's surface.
 * @param  {Function} particleFactory   Called once per star/particle generated. Passed two arguments:
 *                                        `angle`: The direction of the star/particle.
 *                                        `speed`: A multipler for the particle speed, from 0.0 to 1.0.
 * @param  {Number} startAngle=0        For segmented bursts, you can generate only a partial arc of particles. This
 *                                      allows setting the starting arc angle (radians).
 * @param  {Number} arcLength=TAU       The length of the arc (radians). Defaults to a full circle.
 *
 * @return {void}              Returns nothing; it's up to `particleFactory` to use the given data.
 */
function createBurst(count, particleFactory, startAngle=0, arcLength=PI_2) {
  // Assuming sphere with surface area of `count`, calculate various
  // properties of said sphere (unit is stars).
  // Radius
  const R = 0.5 * Math.sqrt(count/Math.PI);
  // Circumference
  const C = 2 * R * Math.PI;
  // Half Circumference
  const C_HALF = C / 2;

  // Make a series of rings, sizing them as if they were spaced evenly
  // along the curved surface of a sphere.
  for (let i=0; i<=C_HALF; i++) {
    const ringAngle = i / C_HALF * PI_HALF;
    const ringSize = Math.cos(ringAngle);
    const partsPerFullRing = C * ringSize;
    const partsPerArc = partsPerFullRing * (arcLength / PI_2);

    const angleInc = PI_2 / partsPerFullRing;
    const angleOffset = Math.random() * angleInc + startAngle;
    // Each particle needs a bit of randomness to improve appearance.
    const maxRandomAngleOffset = angleInc * 0.33;

    for (let i=0; i<partsPerArc; i++) {
      const randomAngleOffset = Math.random() * maxRandomAngleOffset;
      let angle = angleInc * i + angleOffset + randomAngleOffset;
      particleFactory(angle, ringSize);
    }
  }
}




// Various star effects.
// These are designed to be attached to a star's `onDeath` event.

// Crossette breaks star into four same-color pieces which branch in a cross-like shape.
function crossetteEffect(star) {
  const startAngle = Math.random() * PI_HALF;
  createParticleArc(startAngle, PI_2, 4, 0.5, (angle) => {
    Star.add(
      star.x,
      star.y,
      star.color,
      angle,
      Math.random() * 0.6 + 0.75,
      600
    );
  });
}

// Flower is like a mini shell
function floralEffect(star) {
  const count = 12 + 6 * quality;
  createBurst(count, (angle, speedMult) => {
    Star.add(
      star.x,
      star.y,
      star.color,
      angle,
      speedMult * 2.4,
      1000 + Math.random() * 300,
      star.speedX,
      star.speedY
    );
  });
  // Queue burst flash render
  BurstFlash.add(star.x, star.y, 46);
  soundManager.playSound('burstSmall');
}

// Floral burst with willow stars
function fallingLeavesEffect(star) {
  createBurst(7, (angle, speedMult) => {
    const newStar = Star.add(
      star.x,
      star.y,
      INVISIBLE,
      angle,
      speedMult * 2.4,
      2400 + Math.random() * 600,
      star.speedX,
      star.speedY
    );

    newStar.sparkColor = COLOR.Gold;
    newStar.sparkFreq = 144 / quality;
    newStar.sparkSpeed = 0.28;
    newStar.sparkLife = 750;
    newStar.sparkLifeVariation = 3.2;
  });
  // Queue burst flash render
  BurstFlash.add(star.x, star.y, 46);
  soundManager.playSound('burstSmall');
}

// Crackle pops into a small cloud of golden sparks.
function crackleEffect(star) {
  const count = isHighQuality ? 32 : 16;
  createParticleArc(0, PI_2, count, 1.8, (angle) => {
    Spark.add(
      star.x,
      star.y,
      COLOR.Gold,
      angle,
      // apply near cubic falloff to speed (places more particles towards outside)
      Math.pow(Math.random(), 0.45) * 2.4,
      300 + Math.random() * 200
    );
  });
}



/**
 * Shell can be constructed with options:
 *
 * spreadSize:      Size of the burst.
 * starCount: Number of stars to create. This is optional, and will be set to a reasonable quantity for size if omitted.
 * starLife:
 * starLifeVariation:
 * color:
 * glitterColor:
 * glitter: One of: 'light', 'medium', 'heavy', 'streamer', 'willow'
 * pistil:
 * pistilColor:
 * streamers:
 * crossette:
 * floral:
 * crackle:
 */
class Shell {
  constructor(options) {
    Object.assign(this, options);
    this.starLifeVariation = options.starLifeVariation || 0.125;
    this.color = options.color || randomColor();
    this.glitterColor = options.glitterColor || this.color;

    // Set default starCount if needed, will be based on shell size and scale exponentially, like a sphere's surface area.
    if (!this.starCount) {
      const density = options.starDensity || 1;
      const scaledSize = this.spreadSize / 54;
      this.starCount = Math.max(6, scaledSize * scaledSize * density);
    }
  }

  launch(position, launchHeight) {
    const width = stageW;
    const height = stageH;
    // Distance from sides of screen to keep shells.
    const hpad = 60;
    // Distance from top of screen to keep shell bursts.
    const vpad = 50;
    // Minimum burst height, as a percentage of stage height
    const minHeightPercent = 0.45;
    // Minimum burst height in px
    const minHeight = height - height * minHeightPercent;

    const launchX = position * (width - hpad * 2) + hpad;
    const launchY = height;
    const burstY = minHeight - (launchHeight * (minHeight - vpad));

    const launchDistance = launchY - burstY;
    // Using a custom power curve to approximate Vi needed to reach launchDistance under gravity and air drag.
    // Magic numbers came from testing.
    const launchVelocity = Math.pow(launchDistance * 0.04, 0.64);

    const comet = this.comet = Star.add(
      launchX,
      launchY,
      typeof this.color === 'string' && this.color !== 'random' ? this.color : COLOR.White,
      Math.PI,
      launchVelocity * (this.horsetail ? 1.2 : 1),
      // Hang time is derived linearly from Vi; exact number came from testing
      launchVelocity * (this.horsetail ? 100 : 400)
    );

    // making comet "heavy" limits air drag
    comet.heavy = true;
    // comet spark trail
    comet.spinRadius = MyMath.random(0.32, 0.85);
    comet.sparkFreq = 32 / quality;
    if (isHighQuality) comet.sparkFreq = 8;
    comet.sparkLife = 320;
    comet.sparkLifeVariation = 3;
    if (this.glitter === 'willow' || this.fallingLeaves) {
      comet.sparkFreq = 20 / quality;
      comet.sparkSpeed = 0.5;
      comet.sparkLife = 500;
    }
    if (this.color === INVISIBLE) {
      comet.sparkColor = COLOR.Gold;
    }

    // Randomly make comet "burn out" a bit early.
    // This is disabled for horsetail shells, due to their very short airtime.
    if (Math.random() > 0.4 && !this.horsetail) {
      comet.secondColor = INVISIBLE;
      comet.transitionTime = Math.pow(Math.random(), 1.5) * 700 + 500;
    }

    comet.onDeath = comet => this.burst(comet.x, comet.y);

    soundManager.playSound('lift');
  }

  burst(x, y) {
    // Set burst speed so overall burst grows to set size. This specific formula was derived from testing, and is affected by simulated air drag.
    const speed = this.spreadSize / 96;

    let color, onDeath, sparkFreq, sparkSpeed, sparkLife;
    let sparkLifeVariation = 0.25;
    // Some death effects, like crackle, play a sound, but should only be played once.
    let playedDeathSound = false;

    if (this.crossette) onDeath = (star) => {
      if (!playedDeathSound) {
        soundManager.playSound('crackleSmall');
        playedDeathSound = true;
      }
      crossetteEffect(star);
    }
    if (this.crackle) onDeath = (star) => {
      if (!playedDeathSound) {
        soundManager.playSound('crackle');
        playedDeathSound = true;
      }
      crackleEffect(star);
    }
    if (this.floral) onDeath = floralEffect;
    if (this.fallingLeaves) onDeath = fallingLeavesEffect;

    if (this.glitter === 'light') {
      sparkFreq = 400;
      sparkSpeed = 0.3;
      sparkLife = 300;
      sparkLifeVariation = 2;
    }
    else if (this.glitter === 'medium') {
      sparkFreq = 200;
      sparkSpeed = 0.44;
      sparkLife = 700;
      sparkLifeVariation = 2;
    }
    else if (this.glitter === 'heavy') {
      sparkFreq = 80;
      sparkSpeed = 0.8;
      sparkLife = 1400;
      sparkLifeVariation = 2;
    }
    else if (this.glitter === 'thick') {
      sparkFreq = 16;
      sparkSpeed = isHighQuality ? 1.65 : 1.5;
      sparkLife = 1400;
      sparkLifeVariation = 3;
    }
    else if (this.glitter === 'streamer') {
      sparkFreq = 32;
      sparkSpeed = 1.05;
      sparkLife = 620;
      sparkLifeVariation = 2;
    }
    else if (this.glitter === 'willow') {
      sparkFreq = 120;
      sparkSpeed = 0.34;
      sparkLife = 1400;
      sparkLifeVariation = 3.8;
    }

    // Apply quality to spark count
    sparkFreq = sparkFreq / quality;

    // Star factory for primary burst, pistils, and streamers.
    let firstStar = true;
    const starFactory = (angle, speedMult) => {
      // For non-horsetail shells, compute an initial vertical speed to add to star burst.
      // The magic number comes from testing what looks best. The ideal is that all shell
      // bursts appear visually centered for the majority of the star life (excl. willows etc.)
      const standardInitialSpeed = this.spreadSize / 1800;

      const star = Star.add(
        x,
        y,
        color || randomColor(),
        angle,
        speedMult * speed,
        // add minor variation to star life
        this.starLife + Math.random() * this.starLife * this.starLifeVariation,
        this.horsetail ? this.comet && this.comet.speedX : 0,
        this.horsetail ? this.comet && this.comet.speedY : -standardInitialSpeed
      );

      if (this.secondColor) {
        star.transitionTime = this.starLife * (Math.random() * 0.05 + 0.32);
        star.secondColor = this.secondColor;
      }

      if (this.strobe) {
        star.transitionTime = this.starLife * (Math.random() * 0.08 + 0.46);
        star.strobe = true;
        // How many milliseconds between switch of strobe state "tick". Note that the strobe pattern
        // is on:off:off, so this is the "on" duration, while the "off" duration is twice as long.
        star.strobeFreq = Math.random() * 20 + 40;
        if (this.strobeColor) {
          star.secondColor = this.strobeColor;
        }
      }

      star.onDeath = onDeath;

      if (this.glitter) {
        star.sparkFreq = sparkFreq;
        star.sparkSpeed = sparkSpeed;
        star.sparkLife = sparkLife;
        star.sparkLifeVariation = sparkLifeVariation;
        star.sparkColor = this.glitterColor;
        star.sparkTimer = Math.random() * star.sparkFreq;
      }
    };


    if (typeof this.color === 'string') {
      if (this.color === 'random') {
        color = null; // falsey value creates random color in starFactory
      } else {
        color = this.color;
      }

      // Rings have positional randomness, but are rotated randomly
      if (this.ring) {
        const ringStartAngle = Math.random() * Math.PI;
        const ringSquash = Math.pow(Math.random(), 2) * 0.85 + 0.15;;

        createParticleArc(0, PI_2, this.starCount, 0, angle => {
          // Create a ring, squashed horizontally
          const initSpeedX = Math.sin(angle) * speed * ringSquash;
          const initSpeedY = Math.cos(angle) * speed;
          // Rotate ring
          const newSpeed = MyMath.pointDist(0, 0, initSpeedX, initSpeedY);
          const newAngle = MyMath.pointAngle(0, 0, initSpeedX, initSpeedY) + ringStartAngle;
          const star = Star.add(
            x,
            y,
            color,
            newAngle,
            // apply near cubic falloff to speed (places more particles towards outside)
            newSpeed,//speed,
            // add minor variation to star life
            this.starLife + Math.random() * this.starLife * this.starLifeVariation
          );

          if (this.glitter) {
            star.sparkFreq = sparkFreq;
            star.sparkSpeed = sparkSpeed;
            star.sparkLife = sparkLife;
            star.sparkLifeVariation = sparkLifeVariation;
            star.sparkColor = this.glitterColor;
            star.sparkTimer = Math.random() * star.sparkFreq;
          }
        });
      }
      // Normal burst
      else {
        createBurst(this.starCount, starFactory);
      }
    }
    else if (Array.isArray(this.color)) {
      if (Math.random() < 0.5) {
        const start = Math.random() * Math.PI;
        const start2 = start + Math.PI;
        const arc = Math.PI;
        color = this.color[0];
        // Not creating a full arc automatically reduces star count.
        createBurst(this.starCount, starFactory, start, arc);
        color = this.color[1];
        createBurst(this.starCount, starFactory, start2, arc);
      } else {
        color = this.color[0];
        createBurst(this.starCount / 2, starFactory);
        color = this.color[1];
        createBurst(this.starCount / 2, starFactory);
      }
    }
    else {
      throw new Error('Invalid shell color. Expected string or array of strings, but got: ' + this.color);
    }

    if (this.pistil) {
      const innerShell = new Shell({
        spreadSize: this.spreadSize * 0.5,
        starLife: this.starLife * 0.6,
        starLifeVariation: this.starLifeVariation,
        starDensity: 1.4,
        color: this.pistilColor,
        glitter: 'light',
        glitterColor: this.pistilColor === COLOR.Gold ? COLOR.Gold : COLOR.White
      });
      innerShell.burst(x, y);
    }

    if (this.streamers) {
      const innerShell = new Shell({
        spreadSize: this.spreadSize * 0.9,
        starLife: this.starLife * 0.8,
        starLifeVariation: this.starLifeVariation,
        starCount: Math.floor(Math.max(6, this.spreadSize / 45)),
        color: COLOR.White,
        glitter: 'streamer'
      });
      innerShell.burst(x, y);
    }

    // Queue burst flash render
    BurstFlash.add(x, y, this.spreadSize / 4);

    // Play sound, but only for "original" shell, the one that was launched.
    // We don't want multiple sounds from pistil or streamer "sub-shells".
    // This can be detected by the presence of a comet.
    if (this.comet) {
      // Scale explosion sound based on current shell size and selected (max) shell size.
      // Shooting selected shell size will always sound the same no matter the selected size,
      // but when smaller shells are auto-fired, they will sound smaller. It doesn't sound great
      // when a value too small is given though, so instead of basing it on proportions, we just
      // look at the difference in size and map it to a range known to sound good.
      // The language of this project was translated into Chinese by Nianbroken
      const maxDiff = 2;
      const sizeDifferenceFromMaxSize = Math.min(maxDiff, shellSizeSelector() - this.shellSize);
      const soundScale = (1 - sizeDifferenceFromMaxSize / maxDiff) * 0.3 + 0.7;
      soundManager.playSound('burst', soundScale);
    }
  }
}



const BurstFlash = {
  active: [],
  _pool: [],

  _new() {
    return {}
  },

  add(x, y, radius) {
    const instance = this._pool.pop() || this._new();

    instance.x = x;
    instance.y = y;
    instance.radius = radius;

    this.active.push(instance);
    return instance;
  },

  returnInstance(instance) {
    this._pool.push(instance);
  }
};



// Helper to generate objects for storing active particles.
// Particles are stored in arrays keyed by color (code, not name) for improved rendering performance.
function createParticleCollection() {
  const collection = {};
  COLOR_CODES_W_INVIS.forEach(color => {
    collection[color] = [];
  });
  return collection;
}


// Star properties (WIP)
// -----------------------
// transitionTime - how close to end of life that star transition happens

const Star = {
  // Visual properties
  drawWidth: 3,
  airDrag: 0.98,
  airDragHeavy: 0.992,

  // Star particles will be keyed by color
  active: createParticleCollection(),
  _pool: [],

  _new() {
    return {};
  },

  add(x, y, color, angle, speed, life, speedOffX, speedOffY) {
    const instance = this._pool.pop() || this._new();

    instance.visible = true;
    instance.heavy = false;
    instance.x = x;
    instance.y = y;
    instance.prevX = x;
    instance.prevY = y;
    instance.color = color;
    instance.speedX = Math.sin(angle) * speed + (speedOffX || 0);
    instance.speedY = Math.cos(angle) * speed + (speedOffY || 0);
    instance.life = life;
    instance.fullLife = life;
    instance.spinAngle = Math.random() * PI_2;
    instance.spinSpeed = 0.8;
    instance.spinRadius = 0;
    instance.sparkFreq = 0; // ms between spark emissions
    instance.sparkSpeed = 1;
    instance.sparkTimer = 0;
    instance.sparkColor = color;
    instance.sparkLife = 750;
    instance.sparkLifeVariation = 0.25;
    instance.strobe = false;

    this.active[color].push(instance);
    return instance;
  },

  // Public method for cleaning up and returning an instance back to the pool.
  // Language translation of this project into Chinese by Nianbroken
  returnInstance(instance) {
    // Call onDeath handler if available (and pass it current star instance)
    instance.onDeath && instance.onDeath(instance);
    // Clean up
    instance.onDeath = null;
    instance.secondColor = null;
    instance.transitionTime = 0;
    instance.colorChanged = false;
    // Add back to the pool.
    this._pool.push(instance);
  }
};


const Spark = {
  // Visual properties
  drawWidth: 0, // set in `configDidUpdate()`
  airDrag: 0.9,

  // Star particles will be keyed by color
  active: createParticleCollection(),
  _pool: [],

  _new() {
    return {};
  },

  add(x, y, color, angle, speed, life) {
    const instance = this._pool.pop() || this._new();

    instance.x = x;
    instance.y = y;
    instance.prevX = x;
    instance.prevY = y;
    instance.color = color;
    instance.speedX = Math.sin(angle) * speed;
    instance.speedY = Math.cos(angle) * speed;
    instance.life = life;

    this.active[color].push(instance);
    return instance;
  },

  // Public method for cleaning up and returning an instance back to the pool.
  returnInstance(instance) {
    // Add back to the pool.
    this._pool.push(instance);
  }
};



const soundManager = {
  baseURL: 'https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/',
  ctx: new (window.AudioContext || window.webkitAudioContext),
  sources: {
    lift: {
      volume: 1,
      playbackRateMin: 0.85,
      playbackRateMax: 0.95,
      fileNames: [
        'lift1.mp3',
        'lift2.mp3',
        'lift3.mp3'
      ]
    },
    burst: {
      volume: 1,
      playbackRateMin: 0.8,
      playbackRateMax: 0.9,
      fileNames: [
        'burst1.mp3',
        'burst2.mp3'
      ]
    },
    burstSmall: {
      volume: 0.25,
      playbackRateMin: 0.8,
      playbackRateMax: 1,
      fileNames: [
        'burst-sm-1.mp3',
        'burst-sm-2.mp3'
      ]
    },
    crackle: {
      volume: 0.2,
      playbackRateMin: 1,
      playbackRateMax: 1,
      fileNames: ['crackle1.mp3']
    },
    crackleSmall: {
      volume: 0.3,
      playbackRateMin: 1,
      playbackRateMax: 1,
      fileNames: ['crackle-sm-1.mp3']
    }
  },

  preload() {
    const allFilePromises = [];

    function checkStatus(response) {
      if (response.status >= 200 && response.status < 300) {
        return response;
      }
      const customError = new Error(response.statusText);
      customError.response = response;
      throw customError;
    }

    const types = Object.keys(this.sources);
    types.forEach(type => {
      const source = this.sources[type];
      const { fileNames } = source;
      const filePromises = [];
      fileNames.forEach(fileName => {
        const fileURL = this.baseURL + fileName;
        // Promise will resolve with decoded audio buffer.
        const promise = fetch(fileURL)
          .then(checkStatus)
          .then(response => response.arrayBuffer())
          .then(data => new Promise(resolve => {
            this.ctx.decodeAudioData(data, resolve);
          }));

        filePromises.push(promise);
        allFilePromises.push(promise);
      });

      Promise.all(filePromises)
        .then(buffers => {
          source.buffers = buffers;
        });
    });

    return Promise.all(allFilePromises);
  },

  pauseAll() {
    this.ctx.suspend();
  },

  resumeAll() {
    // Play a sound with no volume for iOS. This 'unlocks' the audio context when the user first enables sound.
    this.playSound('lift', 0);
    // Chrome mobile requires interaction before starting audio context.
    // The sound toggle button is triggered on 'touchstart', which doesn't seem to count as a full
    // interaction to Chrome. I guess it needs a click? At any rate if the first thing the user does
    // is enable audio, it doesn't work. Using a setTimeout allows the first interaction to be registered.
    // Perhaps a better solution is to track whether the user has interacted, and if not but they try enabling
    // sound, show a tooltip that they should tap again to enable sound.
    setTimeout(() => {
      this.ctx.resume();
    }, 250);
  },

  // Private property used to throttle small burst sounds.
  _lastSmallBurstTime: 0,

  /**
   * Play a sound of `type`. Will randomly pick a file associated with type, and play it at the specified volume
   * and play speed, with a bit of random variance in play speed. This is all based on `sources` config.
   *
   * @param  {string} type - The type of sound to play.
   * @param  {?number} scale=1 - Value between 0 and 1 (values outside range will be clamped). Scales less than one
   *                             descrease volume and increase playback speed. This is because large explosions are
   *                             louder, deeper, and reverberate longer than small explosions.
   *                             Note that a scale of 0 will mute the sound.
   */
  playSound(type, scale=1) {
    // Ensure `scale` is within valid range.
    scale = MyMath.clamp(scale, 0, 1);

    // Disallow starting new sounds if sound is disabled, app is running in slow motion, or paused.
    // Slow motion check has some wiggle room in case user doesn't finish dragging the speed bar
    // *all* the way back.
    if (!canPlaySoundSelector() || simSpeed < 0.95) {
      return;
    }

    // Throttle small bursts, since floral/falling leaves shells have a lot of them.
    if (type === 'burstSmall') {
      const now = Date.now();
      if (now - this._lastSmallBurstTime < 20) {
        return;
      }
      this._lastSmallBurstTime = now;
    }

    const source = this.sources[type];

    if (!source) {
      throw new Error(`Sound of type "${type}" doesn't exist.`);
    }

    const initialVolume = source.volume;
    const initialPlaybackRate = MyMath.random(
      source.playbackRateMin,
      source.playbackRateMax
    );

    // Volume descreases with scale.
    const scaledVolume = initialVolume * scale;
    // Playback rate increases with scale. For this, we map the scale of 0-1 to a scale of 2-1.
    // So at a scale of 1, sound plays normally, but as scale approaches 0 speed approaches double.
    const scaledPlaybackRate = initialPlaybackRate * (2 - scale);

    const gainNode = this.ctx.createGain();
    gainNode.gain.value = scaledVolume;

    const buffer = MyMath.randomChoice(source.buffers);
    const bufferSource = this.ctx.createBufferSource();
    bufferSource.playbackRate.value = scaledPlaybackRate;
    bufferSource.buffer = buffer;
    bufferSource.connect(gainNode);
    gainNode.connect(this.ctx.destination);
    bufferSource.start(0);
  }
};




// Kick things off.

function setLoadingStatus(status) {
  document.querySelector('.loading-init__status').textContent = status;
}

// CodePen profile header doesn't need audio, just initialize.
if (IS_HEADER) {
  init();
} else {
  // Allow status to render, then preload assets and start app.
  setLoadingStatus('正在点燃导火线');
  setTimeout(() => {
    soundManager.preload()
      .then(
        init,
        reason => {
          // Codepen preview doesn't like to load the audio, so just init to fix the preview for now.
          init();
          // setLoadingStatus('Error Loading Audio');
          return Promise.reject(reason);
        }
      );
  }, 0);
}
